fix(deploy): serve the custom domain, and make it the canonical origin - #6
Merged
Merged
Conversation
actuo.programmersingh.dev was attached to the Render service — DNS and the
certificate worked, /api/health answered 200 — but every page returned
400 text/plain: Header "host" with value "actuo.programmersingh.dev" is not
allowed. NG_ALLOWED_HOSTS was "*.onrender.com", which the new host does not
match. It is a comma list, so both hostnames are now listed; the custom domain
exactly rather than as *.programmersingh.dev, which would admit every other
subdomain of that zone.
Two things the live probe showed that are worth recording:
- /sitemap.xml and /robots.txt answered 200 on a hostname where every SSR route
400'd, because express.static runs ahead of Angular's host check. That is what
a half-broken deploy looks like.
- Fixing the allowlist alone was not enough. PUBLIC_ORIGIN is baked at build
time, so the custom domain was already serving a sitemap, canonical and
og:image all naming actuo.onrender.com — a working site telling crawlers it
lives somewhere else. No check caught it.
So PUBLIC_ORIGIN moves to the custom domain and server.mjs now reads it a second
time at runtime, 308-redirecting page requests on any other hostname to it. One
variable rather than a CANONICAL_ORIGIN that would have to stay in step with it,
which is the same reasoning that keeps CONVERTER_URL a single value.
The redirect is in backend/src/common/canonical-redirect.ts rather than inline
in server.mjs because that is the only workspace with a test runner that can
reach it; server.mjs imports it from dist exactly as it already imports
createNestApp. Its placement does two jobs without a check: after Nest, so
setGlobalPrefix('/api') means /api can never be redirected (the health probe
included), and before the Angular handler, so it also covers the static files
that skip Angular's host check. GET/HEAD only, never loopback, and the target is
always built from PUBLIC_ORIGIN rather than from the request, so a hostile Host
header decides only whether to redirect, never where to.
A test caught a real bug in it: a blanket /:\d+$/ port-strip turns ::1 into ":",
which matches no loopback entry and would have redirected.
verify:deploy gains the two checks that would have caught this: the stamped
origin must MATCH the URL being verified — a missing sentinel only proves
something was substituted, not that the right thing was — and an alias origin is
recognised, reported, and its page checks skipped rather than silently following
the redirect and describing the canonical origin instead.
Progress.md also records cross-origin as proven from the deployed Actuo, which
closes the last rough edge there.
PUBLIC_ORIGIN and NG_ALLOWED_HOSTS have to move together, and getting that wrong is the one way the canonical redirect can take the whole site down: with PUBLIC_ORIGIN on a host the allowlist does not cover, the alias 308s to a host Angular answers 400 for. Every page dead — and /api/health still returns 200, so Render reports a healthy deploy and never rolls back. canonicalRedirect now takes NG_ALLOWED_HOSTS and disables itself when the canonical hostname is not in it, logging both values. The failure degrades to "both hosts keep serving", which is wrong rather than down. An unset allowlist lands here too, correctly: Angular then permits only angular.json's localhost/127.0.0.1. parseAllowedHosts and isHostAllowed mirror @angular/ssr's getArrayFromEnv and isHostAllowed. Duplicating a matcher usually invites drift, but it is safe in the only direction that matters here, because the check can *only* disable a redirect: drift that wrongly says "allowed" leaves the behaviour it would have had without the guard, and drift that wrongly says "not allowed" still serves every host. Angular's list stays the authority on what is served; this decides only whether to redirect. Verified against the composed server both ways — misconfigured, the alias returns 200 and the log explains why; configured, the 308 and every other behaviour are unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
actuo.programmersingh.devis attached to the Render service — DNS and thecertificate work,
/api/healthanswers 200 — but every page returns 400:NG_ALLOWED_HOSTSwas"*.onrender.com", which the new host does not match.Two things the live probe showed that are worth recording:
/sitemap.xmland/robots.txtanswered 200 on a hostname where every SSRroute 400'd, because
express.staticruns ahead of Angular's host check. Thatis what a half-broken deploy looks like.
PUBLIC_ORIGINis baked atbuild time, so the custom domain was already serving a sitemap,
canonicaland
og:imageall namingactuo.onrender.com— a working site tellingcrawlers it lives somewhere else. No check caught it.
What
NG_ALLOWED_HOSTSlists both hostnames (comma-separated; the custom domainexactly rather than
*.programmersingh.dev, which would admit every othersubdomain of that zone).
PUBLIC_ORIGINmoves to the custom domain, andserver.mjsreads it a second time at runtime to 308 page requests on any otherhostname to it — one variable rather than a
CANONICAL_ORIGINthat would haveto stay in step, the same reasoning that keeps
CONVERTER_URLa single value.The redirect lives in
backend/src/common/canonical-redirect.tsbecause that isthe only workspace with a test runner that can reach it;
server.mjsimports itfrom
distexactly as it already importscreateNestApp. Its placement does twojobs without a check: after Nest, so
setGlobalPrefix('/api')means/apican never be redirected (health probe included), and before the Angular
handler, so it also covers the static files that skip Angular's host check.
The guard, and why it is not optional
PUBLIC_ORIGINandNG_ALLOWED_HOSTSmust move together. With the first on ahost the second does not cover, the alias 308s to a host Angular answers 400 for
— every page dead, while
/api/healthstill returns 200, so Render reports ahealthy deploy and never rolls back.
So the redirect refuses to run in that state and logs both values. The failure
degrades to "both hosts keep serving".
parseAllowedHosts/isHostAllowedduplicate Angular's matcher, which is safe in the only direction that matters:
the check can only disable a redirect, so drift cannot make anything worse
than not having it.
Deploying — the ordering matters
PUBLIC_ORIGINis consumed at build time, and merging triggers a rebuild.render.yamlcarries both variables.PUBLIC_ORIGINin the dashboard first, then merge.Either way the guard makes a mistake here non-fatal.
Verify both origins afterwards:
Also
verify:deploygains the two checks that would have caught this: the stampedorigin must match the URL being verified (a missing sentinel only proves
something was substituted, not that the right thing was), and an alias origin is
recognised and reported rather than silently followed.
Progress.mdrecords cross-origin as proven from the deployed Actuo, closing thelast rough edge there.
Checks
pnpm test,pnpm run test:e2e,pnpm run buildthe alias returns 200 and the log explains why; configured, the 308 fires and
/api, loopback and non-GET are all untouched/:\d+$/port-strip turns::1into
":", which matches no loopback entry and would have redirected